昨天的 Day 10,我們學會了將 Controller 和 Service 分開,讓程式架構變得更清楚。
目前的智慧健康助理已經可以接收 JSON、計算 BMI,並回傳結果。
不過今天我突然想到一個問題:
如果使用者輸入的資料不完整,甚至輸入一些奇怪的數字,程式應該怎麼辦?
例如:
{
"height": -162,
"weight": 50
}
或是:
{
"weight": 50
}
這些資料顯然不適合直接拿來計算 BMI。
雖然之前已經使用 if 判斷資料是否合理,但如果未來有十幾個健康資料欄位,難道每個都要自己寫判斷嗎?
所以今天決定學習 Spring Boot 的資料驗證功能!
今天的目標是:
使用 DTO、@Valid 和驗證註解,讓 Spring Boot 自動檢查使用者輸入的資料。
目前我們的 BMI API 可以接收:
{
"height": 162,
"weight": 50
}
但使用者不一定每次都會輸入正確資料。
例如:
| 輸入情況 | 可能問題 |
|---|---|
| 身高為 0 | 無法正常計算 BMI |
| 體重為負數 | 資料不合理 |
| 沒有輸入身高 | 缺少必要資料 |
| 身高輸入文字 | 資料型別錯誤 |
| 身高超出合理範圍 | 可能輸入錯誤 |
如果沒有適當的驗證,程式可能產生錯誤結果,甚至無法正常處理請求。
因此我們希望在進行 BMI 計算之前,先確認資料是否符合基本規則。
整個流程應該是:
使用者傳送 JSON
↓
Spring Boot 接收資料
↓
檢查資料是否符合規則
↓
是否有效?
/ \
是 否
↓ ↓
計算 BMI 回傳錯誤
↓
回傳結果
這就是今天要完成的功能。
昨天整理專案架構時,我們已經提到 DTO。
DTO 的全名是:
Data Transfer Object
中文可以理解成「資料傳輸物件」。
它主要用來在不同層之間傳遞資料。
例如我們的:
HealthRequest
就是一個 DTO。
它負責接收使用者傳入的健康資料。
原本的程式:
public class HealthRequest {
private double height;
private double weight;
// Getter 和 Setter
}
不過這樣只能儲存資料,還沒有設定資料的驗證規則。
所以今天要幫 DTO 加上一些限制。
要使用 Spring Boot 的資料驗證功能,通常需要加入:
Spring Boot Starter Validation
如果使用 Maven,可以在 pom.xml 的 <dependencies> 裡加入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
加入之後,重新載入 Maven 專案,讓相關套件下載完成。
這個 Starter 提供 Jakarta Bean Validation 的相關支援。
接下來就可以使用:
@NotNull
以及:
@Positive
等驗證註解。
第一個要認識的是:
@NotNull
它的用途是:
要求欄位不能是 null。
例如:
@NotNull
private Double height;
代表使用者必須提供身高資料。
如果傳送:
{
"weight": 50
}
因為缺少 height,驗證就不會通過。
這裡有一個需要注意的地方。
原本我們使用:
private double height;
今天改成:
private Double height;
為什麼呢?
因為 double 是 Java 的基本資料型別,預設值是 0.0。
但 Double 是包裝類別,可以表示 null。
如果使用者沒有提供資料,我們就能利用 @NotNull 判斷欄位是否缺少。
第二個註解是:
@Positive
它代表:
數值必須大於 0。
例如:
@Positive
private Double weight;
如果使用者輸入:
{
"weight": -50
}
就無法通過驗證。
對 BMI 計算來說,身高和體重都必須大於 0。
因此我們可以同時使用:
@NotNull
@Positive
private Double height;
以及:
@NotNull
@Positive
private Double weight;
這樣就不需要在 Controller 裡面重複寫所有基本判斷。
不過要注意,@Positive 只檢查是否大於 0,並不代表數值一定符合真實人體範圍。
接下來正式修改昨天建立的:
HealthRequest.java
完整程式如下:
package com.example.healthassistant.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public class HealthRequest {
@NotNull(message = "Height is required.")
@Positive(message = "Height must be greater than 0.")
private Double height;
@NotNull(message = "Weight is required.")
@Positive(message = "Weight must be greater than 0.")
private Double weight;
public HealthRequest() {
}
public Double getHeight() {
return height;
}
public void setHeight(Double height) {
this.height = height;
}
public Double getWeight() {
return weight;
}
public void setWeight(Double weight) {
this.weight = weight;
}
}
這次最重要的改變,就是在欄位上加入驗證註解。
例如:
@NotNull(message = "Height is required.")
如果沒有提供身高,就可以產生對應的錯誤訊息。
而:
@Positive(message = "Height must be greater than 0.")
則可以避免輸入 0 或負數。
這樣就能把資料驗證規則直接寫在 DTO 裡面。
DTO 已經加入驗證規則,但 Spring Boot 要怎麼知道我們希望它執行驗證?
這時候就需要:
@Valid
它可以告訴 Spring:
在將 Request Body 轉換成 Java 物件後,依照 DTO 上的驗證註解檢查資料。
例如:
@PostMapping("/health/bmi")
public ResponseEntity<Map<String, Object>> calculateBMI(
@Valid @RequestBody HealthRequest request) {
和昨天相比,只多了一個:
@Valid
但它就可以啟動 DTO 的驗證機制。
如果資料不符合規則,Spring MVC 通常會在進入 Controller Method 之前回傳 HTTP 400 錯誤。
接下來修改 Controller。
今天的 Controller 不需要自己處理所有驗證工作,而是讓 @Valid 負責檢查輸入資料。
package com.example.healthassistant.controller;
import com.example.healthassistant.dto.HealthRequest;
import com.example.healthassistant.service.BmiService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
public class HealthController {
private final BmiService bmiService;
public HealthController(BmiService bmiService) {
this.bmiService = bmiService;
}
@PostMapping("/health/bmi")
public ResponseEntity<Map<String, Object>> calculateBMI(
@Valid @RequestBody HealthRequest request) {
double height = request.getHeight();
double weight = request.getWeight();
double bmi = bmiService.calculateBMI(height, weight);
Map<String, Object> result = new HashMap<>();
result.put("height", height);
result.put("weight", weight);
result.put("bmi", bmi);
return ResponseEntity.ok(result);
}
}
這樣 Controller 就變得更簡潔了。
現在主要負責:
而身高和體重是否大於 0,就交給 DTO 的驗證註解處理。
昨天 BmiService 裡面的基本防呆可以繼續保留,避免其他程式直接呼叫 Service 時傳入不合理的資料。
接下來使用 Postman 測試。
HTTP Method:
POST
URL:
http://localhost:8080/health/bmi
Body:
{
"height": 162,
"weight": 50
}
如果資料符合規則,預期回傳:
{
"height": 162.0,
"weight": 50.0,
"bmi": 19.05
}
HTTP Status:
200 OK
代表資料通過驗證,並成功完成 BMI 計算。
接下來故意輸入錯誤資料,看看驗證是否有作用。
{
"height": -162,
"weight": 50
}
因為身高不符合:
@Positive
所以驗證不會通過。
預期 HTTP Status:
400 Bad Request
{
"weight": 50
}
因為 height 沒有提供,所以會是 null。
這時候:
@NotNull
就會發揮作用。
預期 HTTP Status:
400 Bad Request
{
"height": 162,
"weight": 0
}
因為:
@Positive
要求數值必須大於 0,因此同樣無法通過驗證。
預期 HTTP Status:
400 Bad Request
目前我們還沒有自訂驗證失敗時的 JSON 格式,所以實際回傳的錯誤內容,可能會因為 Spring Boot 的版本與設定而有所不同。
雖然現在可以自動驗證資料,但我希望使用者能清楚知道是哪個欄位出現問題。
例如:
{
"errors": {
"height": "Height must be greater than 0."
}
}
所以我們可以進一步使用:
@RestControllerAdvice
來統一處理 Controller 發生的例外。
先建立:
GlobalExceptionHandler.java
程式如下:
package com.example.healthassistant.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler